iT邦幫忙

2022 iThome 鐵人賽

DAY 8
3
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 8

[Day 08] JSX 的重要特性與規則以及其背後緣由

  • 分享至 

  • xImage
  •  

為了滿足 transpiler 轉換的正確性,因此 JSX 語法在撰寫上會有一些重要的特性與規則需要注意。這些規則可能大多數人多少都聽過,但是卻不是很了解為什麼會有這些限制。為此,我們也會一併在這個章節中詳細解析這些規則背後的緣由。


嚴格標籤閉合

與 HTML 語法不同,JSX 語法是嚴格標籤閉合的,即使在 HTML 中可以只寫開標籤的元素在 JSX 中也一定要寫對應的閉標籤,例如:

<!-- 在 HTML 語法中,<img> 與 <input> 都能以只寫開標籤來表示 -->
<img src="./image.jpg" class="foo-image">
<input type="text" name="email">
// 在 JSX 中則所有元素都一定要寫閉標籤,即使 children 是空的,否則 JSX transformer 會轉譯失敗
<img src="./image.jpg" class="foo-image"></img>
<input type="text" name="email"></input>

// children 為空的元素可以用自我閉合標籤作為簡寫,與上面的寫法等價
<img src="./image.jpg" class="foo-image" />
<input type="text" name="email" />

當標籤沒有正確的閉合時,JSX transformer 就會沒有辦法解析這層的 React element 結束在哪裡,因此就會因為找不到對應的閉標籤而無法進行轉換。


表達式

JSX 讓我們可以使用類似於 HTML 的語法來定義 React element,但畢竟它不像 HTML 語法一樣是純粹的文字字串,因此當我們想要在 JSX 語法中嵌入一個表達式的時候,就會需要用到 JSX 中指定的語法 {}

const listId = 'list-01';
const listItems = ['item 1', 'item 2', 'item 3'];

const reactElement = React.createElement(
  'ul',
  { id: listId, className: 'foo' },
  listItems.map(item => (
    React.createElement(
      'li',
      { className: 'list-item' },
      `I am ${item}`
    )
  )),
);

const reactElementWithJSX = (
  <ul id={listId} className="foo">
    {listItems.map(item => (
      <li className="list-item">
        I am {item}
      </li>
    ))}
  </ul>
);

上述範例中的兩個 React element 是等價的,我們可以從其中看到當一個屬性的值是靜態的字串的話,就可以用像是 HTML 一樣的語法以 " 雙引號直接包起來,例如 className="foo"

而當一個屬性的值是其他型別或是一個表達式的時候,就需要用 {} 大括號來包起來表示,大括號中可以填入一個 JavaScript 的表達式。

而 children 的部分也差不多,如果是靜態字串或 React element 的話就可以直接寫在開標籤與閉標籤之間,但如果是表達式的話則需要用 {} 語法包起來。


畫面渲染邏輯

由於 React element 本身就是在 JavaScript 中一種普通物件資料,因此不用為了條件判斷或迴圈等需求另外學習特殊的模板指令來操作畫面渲染的邏輯,一切都是直接在 JavaScript 檔案中寫普通的JavaScript 程式碼。

因此我們只要直接用 JS 內建的邏輯判斷來產生 React elements 就可以了:

const items = ['a', 'b', 'c'];
let childElement;
if (items.length >= 1) {
  childElement = <img src="./image.jpg" />;
} else {
  childElement = <input type="text" name="email" />;
}

const appElement = (
  <div>
	{items.map(item => <span>{item}</span>)}
	{childElement}
  </div>
);

而當你已經參透 JSX 的本質是 React.createElemet() 的呼叫之後,當你看到上面這段程式碼的時候,腦袋就會自動理解轉換成:

const items = ['a', 'b', 'c'];
let childElement;

if (items.length >= 1) {
  childElement = React.createElement('img', {
    src: './image.jpg'
  });
} else {
  childElement = React.createElement('input', {
    type: 'text',
    name: 'email'
  });
}

const appElement = React.createElement(
  'div',
  null,
  items.map(item => React.createElement('span', null, item)),
  childElement
);

其實就只是普通的 JavaScript 邏輯來操作普通的 JavaScript 物件資料,並沒有什麼黑魔法對吧?

當然,實務上我們也很常使用 && 或三元運算子之類的語法來幫助我們進行 JSX 結構的條件式渲染,推薦參考官方文件的這篇:Conditional Rendering


為什麼 JSX 的第一層只能有一個節點

當你的 JSX 想表達的的結構中的第一層有多個 React element 節點時,你可能會遇到一個問題:

function Foo() {
  // 以下的 JSX 語法是不合法的 ❌
  return (
    <button>foo</button>
    <div>bar</div>
  );
}

你會發現以上的 JSX 在 transpiler 進行轉譯時會失敗,是一段不合法的 JSX 語法。這是因為一段 JSX 其實就是呼叫一次 React.createElement() ,它只會返回「一個 React element」做為結果,而實際上我們無法一次只用一個值來表達兩個 React elements,所以 transpiler 的 JSX transformer 當然也無法解析這種寫法成為有效的一次 React.createElement() 呼叫語法。

因此,只要你以樹狀資料結構的觀點去思考,就會發現問題其實出在「樹狀資料結構只能有一個根節點」上,所以這個問題真正的解決方法是「把原本想要放置在同層級的多個 React element 節點,以一個共同的父節點給包起來」:

function Foo() {
  // 以下的 JSX 語法合法的 ✅
  return (
    <div>
	  <button>foo</button>
	  <div>bar</div>
	</div>
  );
}

// 經過 JSX 轉譯後:
function Foo() {
  return React.createElement('div', null,
    React.createElement('button', null, 'foo'),
    React.createElement('div', null, 'bar')
  );
}

這樣一來,這段 JSX 所表達的 React element 就會是只有一個,只是這個 React element 裡面還有身為子節點的其他 React elements。

https://i.imgur.com/GF0AtaW.png

不過上面的寫法雖然能解決這個問題,但是我們必須得在第一層多包一層沒有特別意義的元素,如 <div>。雖然這種做法可以解決絕大多數情況,但缺點是最後的瀏覽器中實際的 DOM 結果中有一層無意義的多餘 DOM 元素。當然,如果這段 UI 結構中你本來就想要一個像是 <div> 之類的元素來當作一段 UI 的容器的話,那沒有什麼問題。但是如果你其實並不想要這層 <div> ,純粹只是希望在此處直接表達多個 React element 的話,這樣寫不僅讓畫面結構中產生多餘的資訊而降低可讀性,更有可能因為這層無意義的 DOM element 導致一些 CSS 樣式或專案中針對 DOM 所寫的某些邏輯壞掉。

因此,其實 React 針對這種情況還有另外提供一個推薦的解決方案,就是以 React 內建的 Fragment 來建立一個父節點,並將你想要表達的多個 React elements 包起來:

import { Fragment } from 'react';

function Foo() {
  return (
    <Fragment> 
      <button>foo</button>
	  <div>bar</div>
	</Fragment>
  );
}

Fragment 既不是一種對應實際 DOM element 的 type,也不是一種 component,而是一種 React 內建的特殊 React element type,它專門被設計來應付以上這種情境的需求。當你以 Fragment 來建立一個 React element 時,在瀏覽器的實際 DOM 結果中就不會產生那層多餘的無意義元素。因此,你可以把 Fragment 當成「不會產生實際 DOM 節點的容器用途 React element」:

https://i.imgur.com/O5VIPHv.png

如果你的 Fragment 元素沒有需要傳入任何的 props 的話(就算有也可能通常會是 key prop),可以使用更簡潔的替代語法 — 直接以空標籤名稱來代表 Fragment 元素類型:

function Foo() {
  return (
    <> 
      <button>foo</button>
	  <div>bar</div>
    </>
  );
}

JSX 與 Component

React element 可以用來描述對應真實 DOM element 類型的一種節點資料:

const element = <div id="foo" />;

// 上面的這段 JSX 會被轉譯成:
const element = React.createElement('div', { id: 'foo' })

不過,React element 的類型其實也可以是使用者自定義的 component:

// 在標籤類型的地方填上 function component 名稱
const element = <Welcome name="Zet" />; 

// 上面的這段 JSX 會變轉譯成:
const element = React.createElement(Welcome, { name: 'Zet' }); 

React.createElement 的第一個參數(element type)傳入一個 component 的 function 時,React 就會將第二個參數的屬性當作 props 傳入 component function 中。

舉例來說,下面這段程式碼會在頁面上畫出「Hello, Zet」:

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

const element = <Welcome name="Zet" />;

const root = ReactDOM.createRoot(document.getElementById('root-container'));
root.render(element);

我們來梳理一下上面這個例子當中發生了什麼事:

  1. 我們以 <Welcome name="Zet" /> 這個 React element 呼叫了 root.render()
  2. React 以 { name: 'Zet' } 作為 props 參數來呼叫  Welcome 這個 component function
  3. Welcome component 回傳一個 <h1>Hello, Zet</h1> 這種結構的 React element 作為結果
  4. React DOM 成功的將 root 管轄範圍內的真實的 DOM tree 更新成與 React element 一致,顯示在瀏覽器畫面上

為什麼 Component 命名中的首字母必須為大寫

如前面所說的,React element 的建立其實可以分成幾種類型:

  • 對應真實 DOM 的 React element
    • React.createElement 的第一個參數會以字串來定義,可以傳入支援的 DOM element 類型名稱(例如 'button''div'
  • 對應自定義 component 的 React element
    • React.createElement 的第一個參數會以函式來定義,可以傳入自定義的 component function
  • 當然其實還有一種剛剛有提到的特殊類型 Fragment
    • 從 React 中 import 的 Fragment 其實會是一個 symbol 變數,算是一種專門用來建立特殊 React element 用的 type

然而,在 transpiler 無法直接在 JSX 裡以標籤的語法定義來區分出標籤類型名稱是想表達字串內容還是一個函式名稱(也就是一個變數名稱):

// 應將標籤類型名稱「div」視為字串內容,轉譯成 React.creatElment('div')
const element1 = <div />;

// 應將標籤類型名稱「Welcome」視為變數名稱,轉譯成 React.creatElment(Welcome)
const element2 = <Welcome />;  

因此 transpiler 在做 JSX 轉譯時,就會以這個標籤類型名稱的首字母大小寫來判斷這件事:

  • 當標籤類型名稱的首字母為小寫時,例如 <div>
    • 判斷他是一個對應真實 DOM 的 element type 名稱來做轉譯,將 JSX 語法中的標籤類型名稱視為字串內容當作 React.createElement 的第一個參數傳入
  • 當標籤類型名稱的首字母為大寫時,例如 <Welcome>
    • 判斷他是一個 component function 的名稱來做轉譯,將 JSX 語法中的標籤類型名稱視為變數名稱當作 React.createElement 的第一個參數傳入

這就是為什麼當我們命名一個自定義的 component 時,第一個字母必須是大寫。

除了滿足 JSX 轉換判斷的需求之外,命名首字母是否為大寫在 React 開發的慣例中也能方便開發者分辨其是否為自定義的 component。例如說 <Button> 通常就是指自定義的按鈕 component,而 <button> 則是指對應 DOM 原生 button 元素。


2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 07] JSX 根本就不是在 JavaScript 中寫 HTML
下一篇
[Day 09] 單向資料流 & DOM 渲染策略
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

1
apo7752
iT邦新手 5 級 ‧ 2022-10-10 21:34:53

Zet大安安,
JSX 與 Component 段落的第二個範例程式碼兩個 name 的值不一樣,好像是筆誤~
(也在這感謝分享,身為 React 半年的新手,雖然工作在寫 React 但好像還是熟悉的陌生人 XD,看到現在了解(自己的不足還是很多),祝大大順利完賽!)

Zet iT邦新手 2 級 ‧ 2022-10-10 22:06:18 檢舉

感謝提醒~已修正
React 的學習真的非常需要扎實的觀念理解並回頭融入進實作中,才能漸漸內化到思維中,希望這個鐵人賽系列文有幫助到你,加油哦

1
jacky0326
iT邦新手 5 級 ‧ 2022-10-14 11:38:53

感謝Zet,真的學到很多以往不知道關於react的設計架構,真的很感謝!!

Zet iT邦新手 2 級 ‧ 2022-10-14 12:28:09 檢舉

感謝支持,很高興有所幫助~

我要留言

立即登入留言